ปลดล็อกโค้ดที่เร็วและมีประสิทธิภาพยิ่งขึ้น เรียนรู้เทคนิคสำคัญในการเพิ่มประสิทธิภาพ regex ตั้งแต่ backtracking, การจับคู่แบบ greedy/lazy ไปจนถึงการปรับแต่งขั้นสูง
การเพิ่มประสิทธิภาพ Regular Expression: การเจาะลึกการปรับแต่งประสิทธิภาพ Regex
Regular expressions หรือ regex เป็นเครื่องมือที่ขาดไม่ได้ในชุดเครื่องมือของโปรแกรมเมอร์ยุคใหม่ ตั้งแต่การตรวจสอบความถูกต้องของข้อมูลที่ผู้ใช้ป้อนเข้า การแยกวิเคราะห์ไฟล์ล็อก ไปจนถึงการดำเนินการค้นหาและแทนที่ที่ซับซ้อน และการดึงข้อมูล พลังและความสามารถรอบด้านของมันเป็นสิ่งที่ปฏิเสธไม่ได้ อย่างไรก็ตาม พลังนี้มาพร้อมกับต้นทุนที่ซ่อนอยู่ regex ที่เขียนได้ไม่ดีอาจกลายเป็นตัวการร้ายที่ส่งผลต่อประสิทธิภาพอย่างเงียบๆ ทำให้เกิดความหน่วงแฝงที่สำคัญ ทำให้ CPU ทำงานหนัก และในกรณีที่เลวร้ายที่สุด อาจทำให้แอปพลิเคชันของคุณหยุดทำงานได้ นี่คือจุดที่การเพิ่มประสิทธิภาพ regular expression ไม่ใช่แค่ทักษะ 'มีก็ดี' แต่เป็นทักษะที่สำคัญอย่างยิ่งสำหรับการสร้างซอฟต์แวร์ที่แข็งแกร่งและขยายขนาดได้
คู่มือฉบับสมบูรณ์นี้จะพาคุณเจาะลึกสู่โลกแห่งประสิทธิภาพของ regex เราจะสำรวจว่าทำไมรูปแบบที่ดูเหมือนง่ายๆ ถึงอาจทำงานได้ช้าอย่างหายนะ ทำความเข้าใจการทำงานภายในของเอนจิ้น regex และมอบชุดหลักการและเทคนิคอันทรงพลังเพื่อให้คุณเขียน regular expressions ที่ไม่เพียงแต่ถูกต้อง แต่ยังรวดเร็วอย่างเหลือเชื่ออีกด้วย
ทำความเข้าใจ 'เหตุผล': ต้นทุนของ Regex ที่ไม่ดี
ก่อนที่เราจะเข้าสู่เทคนิคการเพิ่มประสิทธิภาพ สิ่งสำคัญคือต้องเข้าใจปัญหาที่เรากำลังพยายามแก้ไข ปัญหาด้านประสิทธิภาพที่รุนแรงที่สุดที่เกี่ยวข้องกับ regular expressions เรียกว่า Catastrophic Backtracking ซึ่งเป็นสภาวะที่อาจนำไปสู่ช่องโหว่ Regular Expression Denial of Service (ReDoS)
Catastrophic Backtracking คืออะไร?
Catastrophic backtracking เกิดขึ้นเมื่อเอนจิ้น regex ใช้เวลานานมากในการค้นหาสิ่งที่ตรงกัน (หรือตัดสินว่าไม่มีสิ่งใดตรงกัน) สิ่งนี้เกิดขึ้นกับรูปแบบบางประเภทเมื่อเทียบกับสตริงอินพุตบางประเภท เอนจิ้นจะติดอยู่ในวงกตอันน่าเวียนหัวของรูปแบบที่เป็นไปได้ โดยพยายามทุกเส้นทางที่เป็นไปได้เพื่อให้ตรงตามรูปแบบ จำนวนขั้นตอนสามารถเพิ่มขึ้นแบบทวีคูณตามความยาวของสตริงอินพุต ซึ่งนำไปสู่สิ่งที่ดูเหมือนว่าแอปพลิเคชันจะค้าง
พิจารณาตัวอย่างคลาสสิกของ regex ที่มีช่องโหว่: ^(a+)+$
รูปแบบนี้ดูเหมือนจะง่ายพอ: มันมองหาสตริงที่ประกอบด้วย 'a' หนึ่งตัวหรือมากกว่า มันทำงานได้อย่างสมบูรณ์แบบสำหรับสตริงเช่น "a", "aa" และ "aaaaa" ปัญหาเกิดขึ้นเมื่อเราทดสอบกับสตริงที่เกือบจะตรงกันแต่ท้ายที่สุดแล้วล้มเหลว เช่น "aaaaaaaaaaaaaaaaaaaaaaaaaaab"
นี่คือเหตุผลว่าทำไมมันถึงช้ามาก:
(...)+ด้านนอกและa+ด้านในต่างก็เป็น greedy quantifiers (ตัวระบุจำนวนแบบโลภ)a+ด้านในจะจับคู่ 'a' ทั้ง 27 ตัวก่อน(...)+ด้านนอกพอใจกับการจับคู่เพียงครั้งเดียวนี้- จากนั้นเอนจิ้นจะพยายามจับคู่ตัวยึดท้ายสตริง
$ซึ่งล้มเหลวเพราะมี 'b' อยู่ - ตอนนี้เอนจิ้นจะต้อง backtrack (ย้อนรอย) กลุ่มด้านนอกจะยอมปล่อยหนึ่งตัวอักษร ดังนั้น
a+ตอนนี้จะจับคู่ 'a' 26 ตัว และการวนซ้ำครั้งที่สองของกลุ่มด้านนอกจะพยายามจับคู่ 'a' ตัวสุดท้าย ซึ่งก็ล้มเหลวที่ 'b' อีกครั้ง - เอนจิ้นจะพยายามทุกวิถีทางที่เป็นไปได้ในการแบ่งสตริงของ 'a' ระหว่าง
a+ด้านในและ(...)+ด้านนอก สำหรับสตริงที่มี 'a' N ตัว จะมีวิธีแบ่งได้ 2N-1 วิธี ความซับซ้อนเป็นแบบทวีคูณ และเวลาในการประมวลผลก็พุ่งสูงขึ้น
regex ที่ดูเหมือนไม่มีพิษมีภัยเพียงตัวเดียวนี้สามารถล็อกคอร์ CPU ได้นานเป็นวินาที นาที หรือนานกว่านั้น ซึ่งเป็นการปฏิเสธการให้บริการแก่กระบวนการหรือผู้ใช้อื่นๆ ได้อย่างมีประสิทธิภาพ
หัวใจของเรื่อง: เอนจิ้น Regex
เพื่อเพิ่มประสิทธิภาพ regex คุณต้องเข้าใจว่าเอนจิ้นประมวลผลรูปแบบของคุณอย่างไร เอนจิ้น regex มีสองประเภทหลัก และการทำงานภายในของมันเป็นตัวกำหนดลักษณะของประสิทธิภาพ
เอนจิ้น DFA (Deterministic Finite Automaton)
เอนจิ้น DFA คือเจ้าแห่งความเร็วในโลกของ regex พวกมันประมวลผลสตริงอินพุตในรอบเดียวจากซ้ายไปขวา ทีละตัวอักษร ณ จุดใดก็ตาม เอนจิ้น DFA จะรู้แน่ชัดว่าสถานะถัดไปจะเป็นอย่างไรโดยพิจารณาจากอักขระปัจจุบัน ซึ่งหมายความว่ามันไม่ต้อง backtrack เลย เวลาในการประมวลผลเป็นแบบเชิงเส้นและเป็นสัดส่วนโดยตรงกับความยาวของสตริงอินพุต ตัวอย่างเครื่องมือที่ใช้เอนจิ้นแบบ DFA ได้แก่ เครื่องมือ Unix แบบดั้งเดิม เช่น grep และ awk
ข้อดี: ประสิทธิภาพที่รวดเร็วและคาดเดาได้มาก ไม่ได้รับผลกระทบจาก catastrophic backtracking
ข้อเสีย: ชุดคุณสมบัติจำกัด ไม่รองรับคุณสมบัติขั้นสูง เช่น backreferences, lookarounds หรือ capturing groups ซึ่งต้องอาศัยความสามารถในการ backtrack
เอนจิ้น NFA (Nondeterministic Finite Automaton)
เอนจิ้น NFA เป็นประเภทที่พบบ่อยที่สุดในภาษาโปรแกรมสมัยใหม่ เช่น Python, JavaScript, Java, C# (.NET), Ruby, PHP และ Perl พวกมันเป็นแบบ "ขับเคลื่อนด้วยรูปแบบ" (pattern-driven) หมายความว่าเอนจิ้นจะทำตามรูปแบบ และเดินหน้าไปตามสตริง เมื่อถึงจุดที่กำกวม (เช่น alternation | หรือ quantifier *, +) มันจะลองเส้นทางหนึ่ง หากเส้นทางนั้นล้มเหลวในที่สุด มันจะ backtrack กลับไปยังจุดตัดสินใจล่าสุดและลองเส้นทางถัดไปที่มีอยู่
ความสามารถในการ backtrack นี้คือสิ่งที่ทำให้เอนจิ้น NFA ทรงพลังและมีคุณสมบัติมากมาย ทำให้สามารถใช้รูปแบบที่ซับซ้อนด้วย lookarounds และ backreferences ได้ อย่างไรก็ตาม มันก็เป็นจุดอ่อนของมันเช่นกัน เนื่องจากเป็นกลไกที่ทำให้เกิด catastrophic backtracking
สำหรับส่วนที่เหลือของคู่มือนี้ เทคนิคการเพิ่มประสิทธิภาพของเราจะมุ่งเน้นไปที่การควบคุมเอนจิ้น NFA เนื่องจากเป็นส่วนที่นักพัฒนามักจะพบปัญหาด้านประสิทธิภาพมากที่สุด
หลักการพื้นฐานในการเพิ่มประสิทธิภาพสำหรับเอนจิ้น NFA
ตอนนี้ เรามาเจาะลึกเทคนิคที่สามารถนำไปปฏิบัติได้จริงซึ่งคุณสามารถใช้เพื่อเขียน regular expressions ที่มีประสิทธิภาพสูง
1. ระบุให้เฉพาะเจาะจง: พลังของความแม่นยำ
รูปแบบการต่อต้านประสิทธิภาพที่พบบ่อยที่สุดคือการใช้ไวลด์การ์ดที่กว้างเกินไป เช่น .* จุด . จะจับคู่กับอักขระ (เกือบ) ทุกตัว และเครื่องหมายดอกจัน * หมายถึง "ศูนย์ครั้งหรือมากกว่า" เมื่อรวมกัน มันจะสั่งให้เอนจิ้นกินส่วนที่เหลือของสตริงทั้งหมดอย่างโลภ (greedily) แล้วจึง backtrack ทีละตัวอักษรเพื่อดูว่าส่วนที่เหลือของรูปแบบสามารถจับคู่ได้หรือไม่ ซึ่งไม่มีประสิทธิภาพอย่างยิ่ง
ตัวอย่างที่ไม่ดี (การแยกวิเคราะห์ title ของ HTML):
<title>.*</title>
เมื่อใช้กับเอกสาร HTML ขนาดใหญ่ .* จะจับคู่ทุกอย่างจนถึงท้ายไฟล์ก่อน จากนั้นมันจะ backtrack ทีละตัวอักษรจนกว่าจะพบ </title> สุดท้าย ซึ่งเป็นการทำงานที่ไม่จำเป็นอย่างมาก
ตัวอย่างที่ดี (การใช้ negated character class):
<title>[^<]*</title>
เวอร์ชันนี้มีประสิทธิภาพมากกว่ามาก negated character class [^<]* หมายถึง "จับคู่อักขระใดๆ ที่ไม่ใช่ '<' ศูนย์ครั้งหรือมากกว่า" เอนจิ้นจะเดินหน้าไปเรื่อยๆ กินตัวอักษรไปจนกว่าจะเจอ '<' ตัวแรก มันไม่จำเป็นต้อง backtrack เลย นี่เป็นคำสั่งที่ตรงไปตรงมาและไม่กำกวมซึ่งส่งผลให้ประสิทธิภาพเพิ่มขึ้นอย่างมหาศาล
2. เข้าใจความแตกต่างระหว่าง Greed และ Laziness: พลังของเครื่องหมายคำถาม
Quantifiers ใน regex เป็นแบบโลภ (greedy) โดยปริยาย ซึ่งหมายความว่ามันจะจับคู่ข้อความให้ได้มากที่สุดเท่าที่จะเป็นไปได้โดยที่ยังคงทำให้รูปแบบโดยรวมสามารถจับคู่ได้
- Greedy:
*,+,?,{n,m}
คุณสามารถทำให้ quantifier ใดๆ เป็นแบบขี้เกียจ (lazy) ได้โดยการเพิ่มเครื่องหมายคำถามตามหลัง quantifier แบบ lazy จะจับคู่ข้อความให้น้อยที่สุดเท่าที่จะเป็นไปได้
- Lazy:
*?,+?,??,{n,m}?
ตัวอย่าง: การจับคู่แท็กตัวหนา
สตริงอินพุต: <b>First</b> and <b>Second</b>
- รูปแบบ Greedy:
<b>.*</b>
สิ่งนี้จะจับคู่:<b>First</b> and <b>Second</b>ตัว.*ได้กินทุกอย่างไปจนถึง</b>ตัวสุดท้าย - รูปแบบ Lazy:
<b>.*?</b>
สิ่งนี้จะจับคู่<b>First</b>ในการพยายามครั้งแรก และ<b>Second</b>หากคุณค้นหาอีกครั้ง ตัว.*?ได้จับคู่อักขระจำนวนน้อยที่สุดที่จำเป็นเพื่อให้ส่วนที่เหลือของรูปแบบ (</b>) สามารถจับคู่ได้
แม้ว่าความขี้เกียจ (laziness) จะสามารถแก้ปัญหาการจับคู่บางอย่างได้ แต่มันก็ไม่ใช่วิธีแก้ปัญหาที่สมบูรณ์แบบสำหรับประสิทธิภาพ ทุกขั้นตอนของการจับคู่แบบ lazy ต้องการให้เอนจิ้นตรวจสอบว่าส่วนถัดไปของรูปแบบตรงกันหรือไม่ รูปแบบที่เฉพาะเจาะจงสูง (เช่น negated character class จากจุดก่อนหน้า) มักจะเร็วกว่ารูปแบบ lazy
ลำดับประสิทธิภาพ (เร็วที่สุดไปช้าที่สุด):
- Specific/Negated Character Class:
<b>[^<]*</b> - Lazy Quantifier:
<b>.*?</b> - Greedy Quantifier ที่มีการ backtrack จำนวนมาก:
<b>.*</b>
3. หลีกเลี่ยง Catastrophic Backtracking: จัดการกับ Quantifier ที่ซ้อนกัน
ดังที่เราเห็นในตัวอย่างแรก สาเหตุโดยตรงของ catastrophic backtracking คือรูปแบบที่กลุ่มที่มี quantifier ซ้อน quantifier อีกตัวหนึ่งซึ่งสามารถจับคู่ข้อความเดียวกันได้ เอนจิ้นต้องเผชิญกับสถานการณ์ที่กำกวมซึ่งมีหลายวิธีในการแบ่งสตริงอินพุต
รูปแบบที่เป็นปัญหา:
(a+)+(a*)*(a|aa)+(a|b)*เมื่อสตริงอินพุตมี 'a' และ 'b' จำนวนมาก
วิธีแก้คือทำให้รูปแบบไม่กำกวม คุณต้องการให้แน่ใจว่ามีเพียงวิธีเดียวที่เอนจิ้นจะจับคู่สตริงที่กำหนดได้
4. ใช้ Atomic Groups และ Possessive Quantifiers
นี่เป็นหนึ่งในเทคนิคที่ทรงพลังที่สุดสำหรับการตัดการ backtrack ออกจากนิพจน์ของคุณ Atomic groups และ possessive quantifiers บอกเอนจิ้นว่า: "เมื่อคุณจับคู่ส่วนนี้ของรูปแบบแล้ว ห้ามคืนอักขระใดๆ กลับมาเด็ดขาด อย่า backtrack เข้ามาในนิพจน์นี้"
Possessive Quantifiers
Possessive quantifier สร้างขึ้นโดยการเพิ่ม + หลัง quantifier ปกติ (เช่น *+, ++, ?+, {n,m}+) ซึ่งได้รับการสนับสนุนโดยเอนจิ้นเช่น Java, PCRE (PHP, R) และ Ruby
ตัวอย่าง: การจับคู่ตัวเลขที่ตามด้วย 'a'
สตริงอินพุต: 12345
- Regex ปกติ:
\d+a\d+จะจับคู่ "12345" จากนั้นเอนจิ้นพยายามจับคู่ 'a' และล้มเหลว มันจะ backtrack ดังนั้น\d+จึงจับคู่ "1234" และพยายามจับคู่ 'a' กับ '5' มันจะทำเช่นนี้ต่อไปจนกว่า\d+จะคืนอักขระทั้งหมดของมัน ซึ่งเป็นการทำงานที่สิ้นเปลืองมากเพื่อที่จะล้มเหลว - Regex แบบ Possessive:
\d++a\d++จะจับคู่ "12345" แบบ possessive จากนั้นเอนจิ้นพยายามจับคู่ 'a' และล้มเหลว เนื่องจาก quantifier เป็นแบบ possessive เอนจิ้นจึงถูกห้ามไม่ให้ backtrack เข้าไปในส่วน\d++มันจะล้มเหลวทันที สิ่งนี้เรียกว่า 'การล้มเหลวอย่างรวดเร็ว' (failing fast) และมีประสิทธิภาพอย่างยิ่ง
Atomic Groups
Atomic groups มีไวยากรณ์ (?>...) และได้รับการสนับสนุนอย่างกว้างขวางกว่า possessive quantifiers (เช่น ใน .NET, โมดูล `regex` ใหม่ของ Python) พวกมันทำงานเหมือนกับ possessive quantifiers แต่ใช้กับทั้งกลุ่ม
regex (?>\d+)a ทำงานเทียบเท่ากับ \d++a คุณสามารถใช้ atomic groups เพื่อแก้ปัญหา catastrophic backtracking ดั้งเดิมได้:
ปัญหาดั้งเดิม: (a+)+
วิธีแก้ด้วย Atomic: ((?>a+))+
ตอนนี้ เมื่อกลุ่มด้านใน (?>a+) จับคู่ลำดับของ 'a' มันจะไม่ยอมคืนอักขระเหล่านั้นเพื่อให้กลุ่มด้านนอกลองใหม่ มันช่วยขจัดความกำกวมและป้องกันการ backtrack แบบทวีคูณ
5. ลำดับของ Alternation (การเลือก) มีความสำคัญ
เมื่อเอนจิ้น NFA พบกับ alternation (โดยใช้ไปป์ |) มันจะลองทางเลือกจากซ้ายไปขวา ซึ่งหมายความว่าคุณควรวางทางเลือกที่มีแนวโน้มจะเกิดขึ้นมากที่สุดไว้ก่อน
ตัวอย่าง: การแยกวิเคราะห์คำสั่ง
สมมติว่าคุณกำลังแยกวิเคราะห์คำสั่ง และคุณรู้ว่าคำสั่ง `GET` ปรากฏ 80% ของเวลาทั้งหมด, `SET` 15% และ `DELETE` 5%
ประสิทธิภาพน้อยกว่า: ^(DELETE|SET|GET)
ใน 80% ของอินพุตของคุณ เอนจิ้นจะพยายามจับคู่ `DELETE` ก่อน, ล้มเหลว, backtrack, พยายามจับคู่ `SET`, ล้มเหลว, backtrack, และสุดท้ายจะสำเร็จด้วย `GET`
ประสิทธิภาพมากกว่า: ^(GET|SET|DELETE)
ตอนนี้ 80% ของเวลาทั้งหมด เอนจิ้นจะจับคู่ได้สำเร็จตั้งแต่ครั้งแรก การเปลี่ยนแปลงเล็กน้อยนี้สามารถสร้างผลกระทบที่เห็นได้ชัดเมื่อประมวลผลข้อมูลหลายล้านบรรทัด
6. ใช้ Non-Capturing Groups เมื่อไม่ต้องการ Capture
วงเล็บ (...) ใน regex ทำสองสิ่ง: จัดกลุ่มรูปแบบย่อย และจับคู่ข้อความที่ตรงกับรูปแบบย่อยนั้น ข้อความที่ถูกจับคู่นี้จะถูกเก็บไว้ในหน่วยความจำเพื่อใช้ในภายหลัง (เช่น ใน backreferences เช่น `\1` หรือสำหรับการดึงข้อมูลโดยโค้ดที่เรียกใช้) การจัดเก็บนี้มีค่าใช้จ่าย (overhead) เล็กน้อยที่วัดผลได้
หากคุณต้องการเพียงแค่พฤติกรรมการจัดกลุ่ม แต่ไม่ต้องการจับคู่ข้อความ ให้ใช้ non-capturing group: (?:...)
Capturing: (https?|ftp)://([^/]+)
รูปแบบนี้จะจับคู่ "http" และชื่อโดเมนแยกกัน
Non-Capturing: (?:https?|ftp)://([^/]+)
ในที่นี้ เรายังคงจัดกลุ่ม https?|ftp เพื่อให้ :// ทำงานได้อย่างถูกต้อง แต่เราไม่ได้เก็บโปรโตคอลที่จับคู่ได้ ซึ่งมีประสิทธิภาพมากกว่าเล็กน้อยหากคุณสนใจเพียงแค่การดึงชื่อโดเมน (ซึ่งอยู่ในกลุ่มที่ 1)
เทคนิคขั้นสูงและเคล็ดลับเฉพาะเอนจิ้น
Lookarounds: ทรงพลังแต่ต้องใช้อย่างระมัดระวัง
Lookarounds (lookahead (?=...), (?!...) และ lookbehind (?<=...), (?) เป็นการยืนยันที่ไม่มีความกว้าง (zero-width assertions) พวกมันตรวจสอบเงื่อนไขโดยไม่กินอักขระใดๆ จริงๆ ซึ่งอาจมีประสิทธิภาพมากสำหรับการตรวจสอบบริบท
ตัวอย่าง: การตรวจสอบรหัสผ่าน
regex สำหรับตรวจสอบรหัสผ่านที่ต้องมีตัวเลขอย่างน้อยหนึ่งตัว:
^(?=.*\d).{8,}$
นี่มีประสิทธิภาพมาก lookahead (?=.*\d) จะสแกนไปข้างหน้าเพื่อให้แน่ใจว่ามีตัวเลขอยู่ จากนั้นเคอร์เซอร์จะรีเซ็ตกลับไปที่จุดเริ่มต้น ส่วนหลักของรูปแบบ .{8,} ก็เพียงแค่ต้องจับคู่อักขระ 8 ตัวหรือมากกว่า ซึ่งมักจะดีกว่ารูปแบบเส้นทางเดียวที่ซับซ้อนกว่า
การคำนวณล่วงหน้าและการคอมไพล์
ภาษาโปรแกรมส่วนใหญ่มีวิธี "คอมไพล์" regular expression ซึ่งหมายความว่าเอนจิ้นจะแยกวิเคราะห์สตริงรูปแบบเพียงครั้งเดียวและสร้างการแทนค่าภายในที่ได้รับการปรับปรุงประสิทธิภาพแล้ว หากคุณใช้ regex เดิมซ้ำๆ หลายครั้ง (เช่น ภายในลูป) คุณควรคอมไพล์มันเพียงครั้งเดียวนอกลูปเสมอ
ตัวอย่างใน Python:
import re
# คอมไพล์ regex หนึ่งครั้ง
log_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
for line in log_file:
# ใช้อ็อบเจกต์ที่คอมไพล์แล้ว
match = log_pattern.search(line)
if match:
print(match.group(1))
การไม่ทำเช่นนี้จะบังคับให้เอนจิ้นต้องแยกวิเคราะห์สตริงรูปแบบใหม่ทุกครั้งที่วนซ้ำ ซึ่งเป็นการสิ้นเปลืองรอบการทำงานของ CPU อย่างมาก
เครื่องมือสำหรับ Profiling และ Debugging Regex
ทฤษฎีเป็นสิ่งที่ดี แต่การได้เห็นด้วยตาตัวเองนั้นดีกว่า เครื่องมือทดสอบ regex ออนไลน์สมัยใหม่เป็นเครื่องมือที่ทรงคุณค่าอย่างยิ่งในการทำความเข้าใจประสิทธิภาพ
เว็บไซต์อย่าง regex101.com มีฟีเจอร์ "Regex Debugger" หรือ "คำอธิบายขั้นตอน" คุณสามารถวาง regex และสตริงทดสอบของคุณลงไป แล้วมันจะให้การติดตามทีละขั้นตอนว่าเอนจิ้น NFA ประมวลผลสตริงอย่างไร มันแสดงให้เห็นอย่างชัดเจนถึงทุกความพยายามในการจับคู่ ความล้มเหลว และการ backtrack นี่เป็นวิธีที่ดีที่สุดในการเห็นภาพว่าทำไม regex ของคุณถึงช้า และเพื่อทดสอบผลกระทบของการเพิ่มประสิทธิภาพที่เราได้พูดคุยกัน
เช็คลิสต์สำหรับการเพิ่มประสิทธิภาพ Regex
ก่อนที่จะนำ regex ที่ซับซ้อนไปใช้งาน ลองตรวจสอบผ่านเช็คลิสต์ในใจนี้:
- ความเฉพาะเจาะจง: ฉันได้ใช้ lazy
.*?หรือ greedy.*ในจุดที่ negated character class ที่เฉพาะเจาะจงกว่าเช่น[^"\r\n]*จะเร็วกว่าและปลอดภัยกว่าหรือไม่? - Backtracking: ฉันมี quantifiers ที่ซ้อนกันเช่น
(a+)+หรือไม่? มีความกำกวมที่อาจนำไปสู่ catastrophic backtracking ในบางอินพุตหรือไม่? - Possessiveness: ฉันสามารถใช้ atomic group
(?>...)หรือ possessive quantifier*+เพื่อป้องกันการ backtrack เข้าไปในรูปแบบย่อยที่ฉันรู้ว่าไม่ควรถูกประเมินใหม่ได้หรือไม่? - Alternations: ใน alternation
(a|b|c)ของฉัน ทางเลือกที่พบบ่อยที่สุดอยู่เป็นอันดับแรกหรือไม่? - Capturing: ฉันต้องการ capturing groups ทั้งหมดของฉันหรือไม่? บางส่วนสามารถแปลงเป็น non-capturing groups
(?:...)เพื่อลด overhead ได้หรือไม่? - Compilation: หากฉันใช้ regex นี้ในลูป ฉันได้คอมไพล์มันล่วงหน้าหรือไม่?
กรณีศึกษา: การเพิ่มประสิทธิภาพตัวแยกวิเคราะห์ล็อก (Log Parser)
ลองนำทุกอย่างมารวมกัน สมมติว่าเรากำลังแยกวิเคราะห์บรรทัดล็อกของเว็บเซิร์ฟเวอร์มาตรฐาน
บรรทัดล็อก: 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
ก่อน (Regex ที่ช้า):
^(\S+) (\S+) (\S+) \[(.*)\] "(.*)" (\d+) (\d+)$
รูปแบบนี้ใช้งานได้แต่ไม่มีประสิทธิภาพ (.*) สำหรับวันที่และสตริงคำขอจะ backtrack อย่างมาก โดยเฉพาะอย่างยิ่งหากมีบรรทัดล็อกที่ผิดรูปแบบ
หลัง (Regex ที่ปรับปรุงแล้ว):
^(\S+) (\S+) (\S+) \[[^\]]+\] "(?:GET|POST|HEAD) ([^ "]+) HTTP/[\d.]+" (\d{3}) (\d+)$
คำอธิบายการปรับปรุง:
\[(.*)\]กลายเป็น\[[^\]]+\]เราได้แทนที่.*ที่กว้างและมีการ backtrack ด้วย negated character class ที่เฉพาะเจาะจงสูงซึ่งจับคู่ทุกอย่างยกเว้นวงเล็บปิด ไม่จำเป็นต้อง backtrack"(.*)"กลายเป็น"(?:GET|POST|HEAD) ([^ "]+) HTTP/[\d.]+"นี่เป็นการปรับปรุงครั้งใหญ่- เราเจาะจงเกี่ยวกับเมธอด HTTP ที่เราคาดหวัง โดยใช้ non-capturing group
- เราจับคู่พาธ URL ด้วย
[^ "]+(อักขระหนึ่งตัวหรือมากกว่าที่ไม่ใช่ช่องว่างหรือเครื่องหมายคำพูด) แทนไวลด์การ์ดทั่วไป - เราระบุรูปแบบโปรโตคอล HTTP
(\d+)สำหรับรหัสสถานะถูกทำให้รัดกุมขึ้นเป็น(\d{3})เนื่องจากรหัสสถานะ HTTP เป็นตัวเลขสามหลักเสมอ
เวอร์ชัน 'หลัง' ไม่เพียงแต่เร็วกว่าและปลอดภัยจากการโจมตี ReDoS อย่างมากเท่านั้น แต่ยังมีความทนทานมากกว่าเพราะมันตรวจสอบรูปแบบของบรรทัดล็อกอย่างเข้มงวดมากขึ้น
สรุป
Regular expressions เป็นดาบสองคม หากใช้อย่างระมัดระวังและมีความรู้ มันจะเป็นทางออกที่สง่างามสำหรับปัญหาการประมวลผลข้อความที่ซับซ้อน แต่หากใช้อย่างไม่ระมัดระวัง มันอาจกลายเป็นฝันร้ายด้านประสิทธิภาพได้ ข้อคิดสำคัญคือต้องคำนึงถึงกลไกการ backtrack ของเอนจิ้น NFA และเขียนรูปแบบที่นำทางเอนจิ้นไปตามเส้นทางเดียวที่ไม่กำกวมให้บ่อยที่สุดเท่าที่จะเป็นไปได้
โดยการระบุให้เฉพาะเจาะจง, ทำความเข้าใจข้อดีข้อเสียของ greediness และ laziness, ขจัดความกำกวมด้วย atomic groups และใช้เครื่องมือที่เหมาะสมในการทดสอบรูปแบบของคุณ คุณสามารถเปลี่ยน regular expressions ของคุณจากภาระที่อาจเกิดขึ้นให้กลายเป็นทรัพยากรที่มีประสิทธิภาพและทรงพลังในโค้ดของคุณได้ เริ่ม profiling regex ของคุณวันนี้และปลดล็อกแอปพลิเคชันที่เร็วและเชื่อถือได้มากขึ้น